// 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 'package:flutter/foundation.dart';

class _AsyncScope {
  _AsyncScope(this.creationStack, this.zone);
  final StackTrace creationStack;
  final Zone zone;
}

/// Utility class for all the async APIs in the `flutter_test` library.
///
/// This class provides checking for asynchronous APIs, allowing the library to
/// verify that all the asynchronous APIs are properly `await`ed before calling
/// another.
///
/// For example, it prevents this kind of code:
///
/// ```dart
/// tester.pump(); // forgot to call "await"!
/// tester.pump();
/// ```
///
/// ...by detecting, in the second call to `pump`, that it should actually be:
///
/// ```dart
/// await tester.pump();
/// await tester.pump();
/// ```
///
/// It does this while still allowing nested calls, e.g. so that you can
/// call [expect] from inside callbacks.
///
/// You can use this in your own test functions, if you have some asynchronous
/// functions that must be used with "await". Wrap the contents of the function
/// in a call to TestAsyncUtils.guard(), as follows:
///
/// ```dart
/// Future<void> myTestFunction() => TestAsyncUtils.guard(() async {
///   // ...
/// });
/// ```
class TestAsyncUtils {
  TestAsyncUtils._();
  static const String _className = 'TestAsyncUtils';

  static final List<_AsyncScope> _scopeStack = <_AsyncScope>[];

  /// Calls the given callback in a new async scope. The callback argument is
  /// the asynchronous body of the calling method. The calling method is said to
  /// be "guarded". Nested calls to guarded methods from within the body of this
  /// one are fine, but calls to other guarded methods from outside the body of
  /// this one before this one has finished will throw an exception.
  ///
  /// This method first calls [guardSync].
  static Future<T> guard<T>(Future<T> body()) {
    guardSync();
    final Zone zone = Zone.current.fork(
      zoneValues: <dynamic, dynamic>{
        _scopeStack: true, // so we can recognize this as our own zone
      }
    );
    final _AsyncScope scope = _AsyncScope(StackTrace.current, zone);
    _scopeStack.add(scope);
    final Future<T> result = scope.zone.run<Future<T>>(body);
    T resultValue; // This is set when the body of work completes with a result value.
    Future<T> completionHandler(dynamic error, StackTrace stack) {
      assert(_scopeStack.isNotEmpty);
      assert(_scopeStack.contains(scope));
      bool leaked = false;
      _AsyncScope closedScope;
      final List<DiagnosticsNode> information = <DiagnosticsNode>[];
      while (_scopeStack.isNotEmpty) {
        closedScope = _scopeStack.removeLast();
        if (closedScope == scope)
          break;
        if (!leaked) {
          information.add(ErrorSummary('Asynchronous call to guarded function leaked.'));
          information.add(ErrorHint('You must use "await" with all Future-returning test APIs.'));
          leaked = true;
        }
        final _StackEntry originalGuarder = _findResponsibleMethod(closedScope.creationStack, 'guard', information);
        if (originalGuarder != null) {
          information.add(ErrorDescription(
            'The test API method "${originalGuarder.methodName}" '
            'from class ${originalGuarder.className} '
            'was called from ${originalGuarder.callerFile} '
            'on line ${originalGuarder.callerLine}, '
            'but never completed before its parent scope closed.'
          ));
        }
      }
      if (leaked) {
        if (error != null) {
          information.add(DiagnosticsProperty<dynamic>(
            'An uncaught exception may have caused the guarded function leak. The exception was',
            error,
            style: DiagnosticsTreeStyle.errorProperty,
          ));
          information.add(DiagnosticsStackTrace('The stack trace associated with this exception was', stack));
        }
        throw FlutterError.fromParts(information);
      }
      if (error != null)
        return Future<T>.error(error, stack);
      return Future<T>.value(resultValue);
    }
    return result.then<T>(
      (T value) {
        resultValue = value;
        return completionHandler(null, null);
      },
      onError: completionHandler,
    );
  }

  static Zone get _currentScopeZone {
    Zone zone = Zone.current;
    while (zone != null) {
      if (zone[_scopeStack] == true)
        return zone;
      zone = zone.parent;
    }
    return null;
  }

  /// Verifies that there are no guarded methods currently pending (see [guard]).
  ///
  /// If a guarded method is currently pending, and this is not a call nested
  /// from inside that method's body (directly or indirectly), then this method
  /// will throw a detailed exception.
  static void guardSync() {
    if (_scopeStack.isEmpty) {
      // No scopes open, so we must be fine.
      return;
    }
    // Find the current TestAsyncUtils scope zone so we can see if it's the one we expect.
    final Zone zone = _currentScopeZone;
    if (zone == _scopeStack.last.zone) {
      // We're still in the current scope zone. All good.
      return;
    }
    // If we get here, we know we've got a conflict on our hands.
    // We got an async barrier, but the current zone isn't the last scope that
    // we pushed on the stack.
    // Find which scope the conflict happened in, so that we know
    // which stack trace to report the conflict as starting from.
    //
    // For example, if we called an async method A, which ran its body in a
    // guarded block, and in its body it ran an async method B, which ran its
    // body in a guarded block, but we didn't await B, then in A's block we ran
    // an async method C, which ran its body in a guarded block, then we should
    // complain about the call to B then the call to C. BUT. If we called an async
    // method A, which ran its body in a guarded block, and in its body it ran
    // an async method B, which ran its body in a guarded block, but we didn't
    // await A, and then at the top level we called a method D, then we should
    // complain about the call to A then the call to D.
    //
    // In both examples, the scope stack would have two scopes. In the first
    // example, the current zone would be the zone of the _scopeStack[0] scope,
    // and we would want to show _scopeStack[1]'s creationStack. In the second
    // example, the current zone would not be in the _scopeStack, and we would
    // want to show _scopeStack[0]'s creationStack.
    int skipCount = 0;
    _AsyncScope candidateScope = _scopeStack.last;
    _AsyncScope scope;
    do {
      skipCount += 1;
      scope = candidateScope;
      if (skipCount >= _scopeStack.length) {
        if (zone == null)
          break;
        // Some people have reported reaching this point, but it's not clear
        // why. For now, just silently return.
        // TODO(ianh): If we ever get a test case that shows how we reach
        // this point, reduce it and report the error if there is one.
        return;
      }
      candidateScope = _scopeStack[_scopeStack.length - skipCount - 1];
      assert(candidateScope != null);
      assert(candidateScope.zone != null);
    } while (candidateScope.zone != zone);
    assert(scope != null);
    final List<DiagnosticsNode> information = <DiagnosticsNode>[
      ErrorSummary('Guarded function conflict.'),
      ErrorHint('You must use "await" with all Future-returning test APIs.'),
    ];
    final _StackEntry originalGuarder = _findResponsibleMethod(scope.creationStack, 'guard', information);
    final _StackEntry collidingGuarder = _findResponsibleMethod(StackTrace.current, 'guardSync', information);
    if (originalGuarder != null && collidingGuarder != null) {
      String originalName;
      if (originalGuarder.className == null) {
        originalName = '(${originalGuarder.methodName}) ';
        information.add(ErrorDescription(
          'The guarded "${originalGuarder.methodName}" function '
          'was called from ${originalGuarder.callerFile} '
          'on line ${originalGuarder.callerLine}.'
        ));
      } else {
        originalName = '(${originalGuarder.className}.${originalGuarder.methodName}) ';
        information.add(ErrorDescription(
          'The guarded method "${originalGuarder.methodName}" '
          'from class ${originalGuarder.className} '
          'was called from ${originalGuarder.callerFile} '
          'on line ${originalGuarder.callerLine}.'
        ));
      }
      final String again = (originalGuarder.callerFile == collidingGuarder.callerFile) &&
                           (originalGuarder.callerLine == collidingGuarder.callerLine) ?
                           'again ' : '';
      String collidingName;
      if ((originalGuarder.className == collidingGuarder.className) &&
          (originalGuarder.methodName == collidingGuarder.methodName)) {
        originalName = '';
        collidingName = '';
        information.add(ErrorDescription(
          'Then, it '
          'was called ${again}from ${collidingGuarder.callerFile} '
          'on line ${collidingGuarder.callerLine}.'
        ));
      } else if (collidingGuarder.className == null) {
        collidingName = '(${collidingGuarder.methodName}) ';
        information.add(ErrorDescription(
          'Then, the "${collidingGuarder.methodName}" function '
          'was called ${again}from ${collidingGuarder.callerFile} '
          'on line ${collidingGuarder.callerLine}.'
        ));
      } else {
        collidingName = '(${collidingGuarder.className}.${collidingGuarder.methodName}) ';
        information.add(ErrorDescription(
          'Then, the "${collidingGuarder.methodName}" method '
          '${originalGuarder.className == collidingGuarder.className ? "(also from class ${collidingGuarder.className})"
                                                                     : "from class ${collidingGuarder.className}"} '
          'was called ${again}from ${collidingGuarder.callerFile} '
          'on line ${collidingGuarder.callerLine}.'
        ));
      }
      information.add(ErrorDescription(
        'The first ${originalGuarder.className == null ? "function" : "method"} $originalName'
        'had not yet finished executing at the time that '
        'the second ${collidingGuarder.className == null ? "function" : "method"} $collidingName'
        'was called. Since both are guarded, and the second was not a nested call inside the first, the '
        'first must complete its execution before the second can be called. Typically, this is achieved by '
        'putting an "await" statement in front of the call to the first.'
      ));
      if (collidingGuarder.className == null && collidingGuarder.methodName == 'expect') {
        information.add(ErrorHint(
          'If you are confident that all test APIs are being called using "await", and '
          'this expect() call is not being called at the top level but is itself being '
          'called from some sort of callback registered before the ${originalGuarder.methodName} '
          'method was called, then consider using expectSync() instead.'
        ));
      }
      information.add(DiagnosticsStackTrace(
        '\nWhen the first ${originalGuarder.className == null ? "function" : "method"} '
        '$originalName'
        'was called, this was the stack',
        scope.creationStack,
      ));
    }
    throw FlutterError.fromParts(information);
  }

  /// Verifies that there are no guarded methods currently pending (see [guard]).
  ///
  /// This is used at the end of tests to ensure that nothing leaks out of the test.
  static void verifyAllScopesClosed() {
    if (_scopeStack.isNotEmpty) {
      final List<DiagnosticsNode> information = <DiagnosticsNode>[
        ErrorSummary('Asynchronous call to guarded function leaked.'),
        ErrorHint('You must use "await" with all Future-returning test APIs.')
      ];
      for (_AsyncScope scope in _scopeStack) {
        final _StackEntry guarder = _findResponsibleMethod(scope.creationStack, 'guard', information);
        if (guarder != null) {
          information.add(ErrorDescription(
            'The guarded method "${guarder.methodName}" '
            '${guarder.className != null ? "from class ${guarder.className} " : ""}'
            'was called from ${guarder.callerFile} '
            'on line ${guarder.callerLine}, '
            'but never completed before its parent scope closed.'
          ));
        }
      }
      throw FlutterError.fromParts(information);
    }
  }

  static bool _stripAsynchronousSuspensions(String line) {
    return line != '<asynchronous suspension>';
  }

  static _StackEntry _findResponsibleMethod(StackTrace rawStack, String method, List<DiagnosticsNode> information) {
    assert(method == 'guard' || method == 'guardSync');
    final List<String> stack = rawStack.toString().split('\n').where(_stripAsynchronousSuspensions).toList();
    assert(stack.last == '');
    stack.removeLast();
    final RegExp getClassPattern = RegExp(r'^#[0-9]+ +([^. ]+)');
    Match lineMatch;
    int index = -1;
    do { // skip past frames that are from this class
      index += 1;
      assert(index < stack.length);
      lineMatch = getClassPattern.matchAsPrefix(stack[index]);
      assert(lineMatch != null);
      assert(lineMatch.groupCount == 1);
    } while (lineMatch.group(1) == _className);
    // try to parse the stack to find the interesting frame
    if (index < stack.length) {
      final RegExp guardPattern = RegExp(r'^#[0-9]+ +(?:([^. ]+)\.)?([^. ]+)');
      final Match guardMatch = guardPattern.matchAsPrefix(stack[index]); // find the class that called us
      if (guardMatch != null) {
        assert(guardMatch.groupCount == 2);
        final String guardClass = guardMatch.group(1); // might be null
        final String guardMethod = guardMatch.group(2);
        while (index < stack.length) { // find the last stack frame that called the class that called us
          lineMatch = getClassPattern.matchAsPrefix(stack[index]);
          if (lineMatch != null) {
            assert(lineMatch.groupCount == 1);
            if (lineMatch.group(1) == (guardClass ?? guardMethod)) {
              index += 1;
              continue;
            }
          }
          break;
        }
        if (index < stack.length) {
          final RegExp callerPattern = RegExp(r'^#[0-9]+ .* \((.+?):([0-9]+)(?::[0-9]+)?\)$');
          final Match callerMatch = callerPattern.matchAsPrefix(stack[index]); // extract the caller's info
          if (callerMatch != null) {
            assert(callerMatch.groupCount == 2);
            final String callerFile = callerMatch.group(1);
            final String callerLine = callerMatch.group(2);
            return _StackEntry(guardClass, guardMethod, callerFile, callerLine);
          } else {
            // One reason you might get here is if the guarding method was called directly from
            // a 'dart:' API, like from the Future/microtask mechanism, because dart: URLs in the
            // stack trace don't have a column number and so don't match the regexp above.
            information.add(ErrorSummary('(Unable to parse the stack frame of the method that called the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
            information.add(ErrorDescription('${stack[index]}'));
          }
        } else {
          information.add(ErrorSummary('(Unable to find the stack frame of the method that called the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
        }
      } else {
        information.add(ErrorSummary('(Unable to parse the stack frame of the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
        information.add(ErrorDescription('${stack[index]}'));
      }
    } else {
      information.add(ErrorSummary('(Unable to find the method that called $_className.$method(). The stack may be incomplete or bogus.)'));
    }
    return null;
  }
}

class _StackEntry {
  const _StackEntry(this.className, this.methodName, this.callerFile, this.callerLine);
  final String className;
  final String methodName;
  final String callerFile;
  final String callerLine;
}
