// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:async/async.dart';

import '../description.dart';
import '../interfaces.dart';
import '../util.dart';
import 'async_matcher.dart';
import 'stream_matcher.dart';
import 'throws_matcher.dart';
import 'util/pretty_print.dart';

/// Returns a [StreamMatcher] that asserts that the stream emits a "done" event.
final emitsDone = StreamMatcher(
    (queue) async => (await queue.hasNext) ? '' : null, 'be done');

/// Returns a [StreamMatcher] for [matcher].
///
/// If [matcher] is already a [StreamMatcher], it's returned as-is. If it's any
/// other [Matcher], this matches a single event that matches that matcher. If
/// it's any other Object, this matches a single event that's equal to that
/// object.
///
/// This functions like [wrapMatcher] for [StreamMatcher]s: it can convert any
/// matcher-like value into a proper [StreamMatcher].
StreamMatcher emits(Object? matcher) {
  if (matcher is StreamMatcher) return matcher;
  var wrapped = wrapMatcher(matcher);

  var matcherDescription = wrapped.describe(StringDescription());

  return StreamMatcher((queue) async {
    if (!await queue.hasNext) return '';

    var matchState = {};
    var actual = await queue.next;
    if (wrapped.matches(actual, matchState)) return null;

    var mismatchDescription = StringDescription();
    wrapped.describeMismatch(actual, mismatchDescription, matchState, false);

    if (mismatchDescription.length == 0) return '';
    return 'emitted an event that $mismatchDescription';
  },
      // TODO(nweiz): add "should" once matcher#42 is fixed.
      'emit an event that $matcherDescription');
}

/// Returns a [StreamMatcher] that matches a single error event that matches
/// [matcher].
StreamMatcher emitsError(Object? matcher) {
  var wrapped = wrapMatcher(matcher);
  var matcherDescription = wrapped.describe(StringDescription());
  var throwsMatcher = throwsA(wrapped) as AsyncMatcher;

  return StreamMatcher(
      (queue) => throwsMatcher.matchAsync(queue.next) as Future<String?>,
      // TODO(nweiz): add "should" once matcher#42 is fixed.
      'emit an error that $matcherDescription');
}

/// Returns a [StreamMatcher] that allows (but doesn't require) [matcher] to
/// match the stream.
///
/// This matcher always succeeds; if [matcher] doesn't match, this just consumes
/// no events.
StreamMatcher mayEmit(Object? matcher) {
  var streamMatcher = emits(matcher);
  return StreamMatcher((queue) async {
    await queue.withTransaction(
        (copy) async => (await streamMatcher.matchQueue(copy)) == null);
    return null;
  }, 'maybe ${streamMatcher.description}');
}

/// Returns a [StreamMatcher] that matches the stream if at least one of
/// [matchers] matches.
///
/// If multiple matchers match the stream, this chooses the matcher that
/// consumes as many events as possible.
///
/// If any matchers match the stream, no errors from other matchers are thrown.
/// If no matchers match and multiple matchers threw errors, the first error is
/// re-thrown.
StreamMatcher emitsAnyOf(Iterable matchers) {
  var streamMatchers = matchers.map(emits).toList();
  if (streamMatchers.isEmpty) {
    throw ArgumentError('matcher may not be empty');
  }

  if (streamMatchers.length == 1) return streamMatchers.first;
  var description = 'do one of the following:\n'
      '${bullet(streamMatchers.map((matcher) => matcher.description))}';

  return StreamMatcher((queue) async {
    var transaction = queue.startTransaction();

    // Allocate the failures list ahead of time so that its order matches the
    // order of [matchers], and thus the order the matchers will be listed in
    // the description.
    var failures = List<String?>.filled(matchers.length, null);

    // The first error thrown. If no matchers match and this exists, we rethrow
    // it.
    Object? firstError;
    StackTrace? firstStackTrace;

    var futures = <Future>[];
    StreamQueue? consumedMost;
    for (var i = 0; i < matchers.length; i++) {
      futures.add(() async {
        var copy = transaction.newQueue();

        String? result;
        try {
          result = await streamMatchers[i].matchQueue(copy);
        } catch (error, stackTrace) {
          if (firstError == null) {
            firstError = error;
            firstStackTrace = stackTrace;
          }
          return;
        }

        if (result != null) {
          failures[i] = result;
        } else if (consumedMost == null ||
            consumedMost!.eventsDispatched < copy.eventsDispatched) {
          consumedMost = copy;
        }
      }());
    }

    await Future.wait(futures);

    if (consumedMost == null) {
      transaction.reject();
      if (firstError != null) {
        await Future.error(firstError!, firstStackTrace);
      }

      var failureMessages = <String>[];
      for (var i = 0; i < matchers.length; i++) {
        var message = 'failed to ${streamMatchers[i].description}';
        if (failures[i]!.isNotEmpty) {
          message += message.contains('\n') ? '\n' : ' ';
          message += 'because it ${failures[i]}';
        }

        failureMessages.add(message);
      }

      return 'failed all options:\n${bullet(failureMessages)}';
    } else {
      transaction.commit(consumedMost!);
      return null;
    }
  }, description);
}

/// Returns a [StreamMatcher] that matches the stream if each matcher in
/// [matchers] matches, one after another.
///
/// If any matcher fails to match, this fails and consumes no events.
StreamMatcher emitsInOrder(Iterable matchers) {
  var streamMatchers = matchers.map(emits).toList();
  if (streamMatchers.length == 1) return streamMatchers.first;

  var description = 'do the following in order:\n'
      '${bullet(streamMatchers.map((matcher) => matcher.description))}';

  return StreamMatcher((queue) async {
    for (var i = 0; i < streamMatchers.length; i++) {
      var matcher = streamMatchers[i];
      var result = await matcher.matchQueue(queue);
      if (result == null) continue;

      var newResult = "didn't ${matcher.description}";
      if (result.isNotEmpty) {
        newResult += newResult.contains('\n') ? '\n' : ' ';
        newResult += 'because it $result';
      }
      return newResult;
    }
    return null;
  }, description);
}

/// Returns a [StreamMatcher] that matches any number of events followed by
/// events that match [matcher].
///
/// This consumes all events matched by [matcher], as well as all events before.
/// If the stream emits a done event without matching [matcher], this fails and
/// consumes no events.
StreamMatcher emitsThrough(Object? matcher) {
  var streamMatcher = emits(matcher);
  return StreamMatcher((queue) async {
    var failures = <String>[];

    Future<bool> tryHere() => queue.withTransaction((copy) async {
          var result = await streamMatcher.matchQueue(copy);
          if (result == null) return true;
          failures.add(result);
          return false;
        });

    while (await queue.hasNext) {
      if (await tryHere()) return null;
      await queue.next;
    }

    // Try after the queue is done in case the matcher can match an empty
    // stream.
    if (await tryHere()) return null;

    var result = 'never did ${streamMatcher.description}';

    var failureMessages =
        bullet(failures.where((failure) => failure.isNotEmpty));
    if (failureMessages.isNotEmpty) {
      result += result.contains('\n') ? '\n' : ' ';
      result += 'because it:\n$failureMessages';
    }

    return result;
  }, 'eventually ${streamMatcher.description}');
}

/// Returns a [StreamMatcher] that matches any number of events that match
/// [matcher].
///
/// This consumes events until [matcher] no longer matches. It always succeeds;
/// if [matcher] doesn't match, this just consumes no events. It never rethrows
/// errors.
StreamMatcher mayEmitMultiple(Object? matcher) {
  var streamMatcher = emits(matcher);

  var description = streamMatcher.description;
  description += description.contains('\n') ? '\n' : ' ';
  description += 'zero or more times';

  return StreamMatcher((queue) async {
    while (await _tryMatch(queue, streamMatcher)) {
      // Do nothing; the matcher presumably already consumed events.
    }
    return null;
  }, description);
}

/// Returns a [StreamMatcher] that matches a stream that never matches
/// [matcher].
///
/// This doesn't complete until the stream emits a done event. It never consumes
/// any events. It never re-throws errors.
StreamMatcher neverEmits(Object? matcher) {
  var streamMatcher = emits(matcher);
  return StreamMatcher((queue) async {
    var events = 0;
    var matched = false;
    await queue.withTransaction((copy) async {
      while (await copy.hasNext) {
        matched = await _tryMatch(copy, streamMatcher);
        if (matched) return false;

        events++;

        try {
          await copy.next;
        } catch (_) {
          // Ignore errors events.
        }
      }

      matched = await _tryMatch(copy, streamMatcher);
      return false;
    });

    if (!matched) return null;
    return "after $events ${pluralize('event', events)} did "
        '${streamMatcher.description}';
  }, 'never ${streamMatcher.description}');
}

/// Returns whether [matcher] matches [queue] at its current position.
///
/// This treats errors as failures to match.
Future<bool> _tryMatch(StreamQueue queue, StreamMatcher matcher) {
  return queue.withTransaction((copy) async {
    try {
      return (await matcher.matchQueue(copy)) == null;
    } catch (_) {
      return false;
    }
  });
}

/// Returns a [StreamMatcher] that matches the stream if each matcher in
/// [matchers] matches, in any order.
///
/// If any matcher fails to match, this fails and consumes no events. If the
/// matchers match in multiple different possible orders, this chooses the order
/// that consumes as many events as possible.
///
/// If any sequence of matchers matches the stream, no errors from other
/// sequences are thrown. If no sequences match and multiple sequences throw
/// errors, the first error is re-thrown.
///
/// Note that checking every ordering of [matchers] is O(n!) in the worst case,
/// so this should only be called when there are very few [matchers].
StreamMatcher emitsInAnyOrder(Iterable matchers) {
  var streamMatchers = matchers.map(emits).toSet();
  if (streamMatchers.length == 1) return streamMatchers.first;
  var description = 'do the following in any order:\n'
      '${bullet(streamMatchers.map((matcher) => matcher.description))}';

  return StreamMatcher(
      (queue) async => await _tryInAnyOrder(queue, streamMatchers) ? null : '',
      description);
}

/// Returns whether [queue] matches [matchers] in any order.
Future<bool> _tryInAnyOrder(
    StreamQueue queue, Set<StreamMatcher> matchers) async {
  if (matchers.length == 1) {
    return await matchers.first.matchQueue(queue) == null;
  }

  var transaction = queue.startTransaction();
  StreamQueue? consumedMost;

  // The first error thrown. If no matchers match and this exists, we rethrow
  // it.
  Object? firstError;
  StackTrace? firstStackTrace;

  await Future.wait(matchers.map((matcher) async {
    var copy = transaction.newQueue();
    try {
      if (await matcher.matchQueue(copy) != null) return;
    } catch (error, stackTrace) {
      if (firstError == null) {
        firstError = error;
        firstStackTrace = stackTrace;
      }
      return;
    }

    var rest = Set<StreamMatcher>.from(matchers);
    rest.remove(matcher);

    try {
      if (!await _tryInAnyOrder(copy, rest)) return;
    } catch (error, stackTrace) {
      if (firstError == null) {
        firstError = error;
        firstStackTrace = stackTrace;
      }
      return;
    }

    if (consumedMost == null ||
        consumedMost!.eventsDispatched < copy.eventsDispatched) {
      consumedMost = copy;
    }
  }));

  if (consumedMost == null) {
    transaction.reject();
    if (firstError != null) await Future.error(firstError!, firstStackTrace);
    return false;
  } else {
    transaction.commit(consumedMost!);
    return true;
  }
}
