// Copyright (c) 2016, 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 'dart:async';
import 'dart:io';

import 'package:stack_trace/stack_trace.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/backend.dart'
    show Metadata, RemoteException, SuitePlatform, TestLocation;
import 'package:test_api/src/backend/group.dart'; // ignore: implementation_imports
import 'package:test_api/src/backend/test.dart'; // ignore: implementation_imports

import '../configuration.dart';
import '../environment.dart';
import '../load_exception.dart';
import '../runner_suite.dart';
import '../runner_test.dart';
import '../suite.dart';

/// A helper method for creating a [RunnerSuiteController] containing tests
/// that communicate over [channel].
///
/// This returns a controller so that the caller has a chance to control the
/// runner suite's debugging state based on plugin-specific logic.
///
/// If the suite is closed, this will close [channel].
///
/// The [message] parameter is an opaque object passed from the runner to
/// [PlatformPlugin.load]. Plugins shouldn't interact with it other than to pass
/// it on to [deserializeSuite].
///
/// If [mapper] is passed, it will be used to adjust stack traces for any errors
/// emitted by tests.
///
/// [gatherCoverage] is a callback which returns a hit-map containing merged
/// coverage report suitable for use with `package:coverage`.
RunnerSuiteController deserializeSuite(
  String path,
  SuitePlatform platform,
  SuiteConfiguration suiteConfig,
  Environment environment,
  StreamChannel<Object?> channel,
  Object /*Map<String, Object?>*/ message, {
  Future<Map<String, dynamic>> Function()? gatherCoverage,
}) {
  var disconnector = Disconnector<Object?>();
  var suiteChannel = MultiChannel<Object?>(channel.transform(disconnector));

  suiteChannel.sink.add(<String, Object?>{
    'type': 'initial',
    'platform': platform.serialize(),
    'metadata': suiteConfig.metadata.serialize(),
    'asciiGlyphs': Platform.isWindows,
    'path': path,
    'collectTraces':
        Configuration.current.reporter == 'json' ||
        Configuration.current.fileReporters.containsKey('json') ||
        suiteConfig.testSelections.any(
          (selection) => selection.line != null || selection.col != null,
        ),
    'noRetry': Configuration.current.noRetry,
    'foldTraceExcept': Configuration.current.foldTraceExcept.toList(),
    'foldTraceOnly': Configuration.current.foldTraceOnly.toList(),
    'allowDuplicateTestNames': suiteConfig.allowDuplicateTestNames,
    'ignoreTimeouts': suiteConfig.ignoreTimeouts,
    ...message as Map<String, dynamic>,
  });

  var completer = Completer<Group>();

  var loadSuiteZone = Zone.current;
  void handleError(Object error, StackTrace stackTrace) {
    disconnector.disconnect();

    if (completer.isCompleted) {
      // If we've already provided a controller, send the error to the
      // LoadSuite. This will cause the virtual load test to fail, which will
      // notify the user of the error.
      loadSuiteZone.handleUncaughtError(error, stackTrace);
    } else {
      completer.completeError(error, stackTrace);
    }
  }

  suiteChannel.stream.cast<Map<String, Object?>>().listen(
    (response) {
      switch (response['type'] as String) {
        case 'print':
          print(response['line']);
          break;

        case 'loadException':
          handleError(
            LoadException(path, response['message'] as Object),
            Trace.current(),
          );
          break;

        case 'error':
          var asyncError = RemoteException.deserialize(
            response['error'] as Map<String, dynamic>,
          );
          handleError(
            LoadException(path, asyncError.error),
            asyncError.stackTrace,
          );
          break;

        case 'success':
          var deserializer = _Deserializer(suiteChannel);
          completer.complete(
            deserializer.deserializeGroup(response['root'] as Map),
          );
          break;
      }
    },
    onError: handleError,
    onDone: () {
      if (completer.isCompleted) return;
      completer.completeError(
        LoadException(path, 'Connection closed before test suite loaded.'),
        Trace.current(),
      );
    },
  );

  return RunnerSuiteController(
    environment,
    suiteConfig,
    suiteChannel,
    completer.future,
    platform,
    path: path,
    onClose: () => disconnector.disconnect().onError(handleError),
    gatherCoverage: gatherCoverage,
  );
}

/// A utility class for storing state while deserializing tests.
class _Deserializer {
  /// The channel over which tests communicate.
  final MultiChannel _channel;

  _Deserializer(this._channel);

  /// Deserializes [group] into a concrete [Group].
  Group deserializeGroup(Map group) {
    var metadata = Metadata.deserialize(group['metadata'] as Map);
    var trace =
        group['trace'] == null ? null : Trace.parse(group['trace'] as String);
    var location = switch (group['location']) {
      Map map => TestLocation.deserialize(map),
      _ => null,
    };
    var entries =
        (group['entries'] as List).map((entry) {
          var map = entry as Map;
          if (map['type'] == 'group') return deserializeGroup(map);
          return _deserializeTest(map)!;
        }).toList();

    return Group(
      group['name'] as String,
      entries,
      metadata: metadata,
      trace: trace,
      location: location,
      setUpAll: _deserializeTest(group['setUpAll'] as Map?),
      tearDownAll: _deserializeTest(group['tearDownAll'] as Map?),
    );
  }

  /// Deserializes [test] into a concrete [Test] class.
  ///
  /// Returns `null` if [test] is `null`.
  Test? _deserializeTest(Map? test) {
    if (test == null) return null;

    var metadata = Metadata.deserialize(test['metadata'] as Map);
    var trace =
        test['trace'] == null ? null : Trace.parse(test['trace'] as String);
    var location = switch (test['location']) {
      Map map => TestLocation.deserialize(map),
      _ => null,
    };
    var testChannel = _channel.virtualChannel((test['channel'] as num).toInt());
    return RunnerTest(
      test['name'] as String,
      metadata,
      trace,
      location,
      testChannel,
    );
  }
}
