// 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:io';
import 'dart:isolate';

import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:async/async.dart';
import 'package:path/path.dart' as p;
import 'package:stream_channel/isolate_channel.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/backend.dart' show RemoteException;
import 'package:test_api/src/backend/suite.dart'; // ignore: implementation_imports

import '../util/dart.dart' as dart;
import '../util/package_config.dart';
import 'package_version.dart';

/// Spawns a hybrid isolate from [url] with the given [message], and returns a
/// [StreamChannel] that communicates with it.
///
/// This connects the main isolate to the hybrid isolate, whereas
/// `lib/src/frontend/spawn_hybrid.dart` connects the test isolate to the main
/// isolate.
///
/// If [uri] is relative, it will be interpreted relative to the `file:` URL
/// for [suite]. If it's root-relative (that is, if it begins with `/`) it will
/// be interpreted relative to the root of the package (the directory that
/// contains `pubspec.yaml`, *not* the `test/` directory). If it's a `package:`
/// URL, it will be resolved using the current package's dependency
/// constellation.
StreamChannel spawnHybridUri(String url, Object? message, Suite suite) {
  return StreamChannelCompleter.fromFuture(() async {
    url = await _normalizeUrl(url, suite);
    var port = ReceivePort();
    var onExitPort = ReceivePort();
    try {
      var code = '''
        ${await _languageVersionCommentFor(url)}

        import "package:test_core/src/runner/hybrid_listener.dart";

        import "${url.replaceAll(r'$', '%24')}" as lib;

        void main(_, List data) => listen(() => lib.hybridMain, data);
      ''';

      var isolate = await dart.runInIsolate(code, [port.sendPort, message],
          onExit: onExitPort.sendPort);

      // Ensure that we close [port] and [channel] when the isolate exits.
      var disconnector = Disconnector<void>();
      onExitPort.listen((_) {
        disconnector.disconnect();
        port.close();
        onExitPort.close();
      });

      return IsolateChannel<Object?>.connectReceive(port)
          .transform(disconnector)
          .transformSink(StreamSinkTransformer.fromHandlers(handleDone: (sink) {
        // If the user closes the stream channel, kill the isolate.
        isolate.kill();
        port.close();
        onExitPort.close();
        sink.close();
      }));
    } catch (error, stackTrace) {
      port.close();
      onExitPort.close();

      // Make sure any errors in spawning the isolate are forwarded to the test.
      return StreamChannel(
          Stream.fromFuture(Future.value({
            'type': 'error',
            'error': RemoteException.serialize(error, stackTrace)
          })),
          NullStreamSink<void>());
    }
  }());
}

/// Normalizes [url] to an absolute url, resolving `package:` urls with the
/// current package config.
///
/// If [url] has a scheme other than `package:`, then it is returned as is.
///
/// Follows the rules for relative/absolute paths outlined in [spawnHybridUri].
Future<String> _normalizeUrl(String url, Suite suite) async {
  final parsedUri = Uri.parse(url);

  switch (parsedUri.scheme) {
    case '':
      var isRootRelative = parsedUri.path.startsWith('/');

      if (isRootRelative) {
        // We assume that the current path is the package root. `pub run`
        // enforces this currently, but at some point it would probably be good
        // to pass in an explicit root.
        return p.url
            .join(p.toUri(p.current).toString(), parsedUri.path.substring(1));
      } else {
        var suitePath = suite.path!;
        return p.url.join(
            p.url.dirname(p.toUri(p.absolute(suitePath)).toString()),
            parsedUri.toString());
      }
    case 'package':
      final resolvedUri = await Isolate.resolvePackageUri(parsedUri);
      if (resolvedUri == null) {
        throw ArgumentError.value(
            url, 'uri', 'Could not resolve the package URI');
      }
      return resolvedUri.toString();
    default:
      return url;
  }
}

/// Computes the a language version comment for the library at [uri].
///
/// If there is a language version comment in the file, that is returned.
///
/// If the URI has a `data` scheme, a comment representing the language version of
/// the current package is returned.
///
/// Otherwise a comment representing the default version from the
/// [currentPackageConfig] is returned.
///
/// If no default language version is known (the URI scheme is not recognized
/// for instance), then an empty string is returned.
Future<String> _languageVersionCommentFor(String url) async {
  var parsedUri = Uri.parse(url);

  // Returns the explicit language version comment if one exists.
  var result = parseString(
      content: await _readUri(parsedUri),
      path: parsedUri.scheme == 'data' ? null : p.fromUri(parsedUri),
      throwIfDiagnostics: false);
  var languageVersionComment = result.unit.languageVersionToken?.value();
  if (languageVersionComment != null) return languageVersionComment.toString();

  // Returns the default language version for the package if one exists.
  if (parsedUri.scheme.isEmpty || parsedUri.scheme == 'file') {
    var packageConfig = await currentPackageConfig;
    var package = packageConfig.packageOf(parsedUri);
    var version = package?.languageVersion;
    if (version != null) return '// @dart=$version';
  }

  // Returns the root package language version for `data` URIs. These are
  // assumed to be from `spawnHybridCode` calls.
  if (parsedUri.scheme == 'data') {
    return await rootPackageLanguageVersionComment;
  }

  // Fall back on no language comment.
  return '';
}

Future<String> _readUri(Uri uri) async => switch (uri.scheme) {
      '' || 'file' => await File.fromUri(uri).readAsString(),
      'data' => uri.data!.contentAsString(),
      _ => throw ArgumentError.value(uri, 'uri',
          'Only data and file uris (as well as relative paths) are supported'),
    };
