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

import 'package:async/async.dart';
import 'package:node_preamble/preamble.dart' as preamble;
import 'package:package_config/package_config.dart';
import 'package:path/path.dart' as p;
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/backend.dart'
    show Compiler, Runtime, StackTraceMapper, SuitePlatform;
import 'package:test_core/src/runner/application_exception.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/configuration.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/dart2js_compiler_pool.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/load_exception.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/package_version.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/customizable_platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/environment.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/platform_helpers.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/wasm_compiler_pool.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/errors.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/package_config.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
import 'package:yaml/yaml.dart';

import '../../util/package_map.dart';
import '../executable_settings.dart';

/// A platform that loads tests in Node.js processes.
class NodePlatform extends PlatformPlugin
    implements CustomizablePlatform<ExecutableSettings> {
  /// The test runner configuration.
  final Configuration _config;

  /// The [Dart2JsCompilerPool] managing active instances of `dart2js`.
  final _jsCompilers = Dart2JsCompilerPool(['-Dnode=true', '--server-mode']);
  final _wasmCompilers = WasmCompilerPool(['-Dnode=true']);

  /// The temporary directory in which compiled JS is emitted.
  final _compiledDir = createTempDir();

  /// Executable settings for [Runtime.nodeJS] and runtimes that extend
  /// it.
  final _settings = {
    Runtime.nodeJS: ExecutableSettings(
      linuxExecutable: 'node',
      macOSExecutable: 'node',
      windowsExecutable: 'node.exe',
    ),
  };

  NodePlatform() : _config = Configuration.current;

  @override
  ExecutableSettings parsePlatformSettings(YamlMap settings) =>
      ExecutableSettings.parse(settings);

  @override
  ExecutableSettings mergePlatformSettings(
    ExecutableSettings settings1,
    ExecutableSettings settings2,
  ) => settings1.merge(settings2);

  @override
  void customizePlatform(Runtime runtime, ExecutableSettings settings) {
    var oldSettings = _settings[runtime] ?? _settings[runtime.root];
    if (oldSettings != null) settings = oldSettings.merge(settings);
    _settings[runtime] = settings;
  }

  @override
  Future<RunnerSuite> load(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    Map<String, Object?> message,
  ) async {
    if (platform.compiler != Compiler.dart2js &&
        platform.compiler != Compiler.dart2wasm) {
      throw StateError(
        'Unsupported compiler for the Node platform ${platform.compiler}.',
      );
    }
    var (channel, stackMapper) = await _loadChannel(
      path,
      platform,
      suiteConfig,
    );
    var controller = deserializeSuite(
      path,
      platform,
      suiteConfig,
      const PluginEnvironment(),
      channel,
      message,
    );

    controller.channel('test.node.mapper').sink.add(stackMapper?.serialize());

    return await controller.suite;
  }

  /// Loads a [StreamChannel] communicating with the test suite at [path].
  ///
  /// Returns that channel along with a [StackTraceMapper] representing the
  /// source map for the compiled suite.
  Future<(StreamChannel<Object?>, StackTraceMapper?)> _loadChannel(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
  ) async {
    final servers = await _loopback();

    try {
      var (process, stackMapper) = await _spawnProcess(
        path,
        platform,
        suiteConfig,
        servers.first.port,
      );

      // Forward Node's standard IO to the print handler so it's associated with
      // the load test.
      //
      // TODO(nweiz): Associate this with the current test being run, if any.
      process.stdout.transform(lineSplitter).listen(print);
      process.stderr.transform(lineSplitter).listen(print);

      // Wait for the first connection (either over ipv4 or v6). If the proccess
      // exits before it connects, throw instead of waiting for a connection
      // indefinitely.
      var socket = await Future.any([
        StreamGroup.merge(servers).first,
        process.exitCode.then((_) => null),
      ]);

      if (socket == null) {
        throw LoadException(
          path,
          'Node exited before connecting to the test channel.',
        );
      }

      var channel = StreamChannel(socket.cast<List<int>>(), socket)
          .transform(StreamChannelTransformer.fromCodec(utf8))
          .transform(_chunksToLines)
          .transform(jsonDocument)
          .transformStream(
            StreamTransformer.fromHandlers(
              handleDone: (sink) {
                process.kill();
                sink.close();
              },
            ),
          );

      return (channel, stackMapper);
    } finally {
      unawaited(
        Future.wait<void>(
          servers.map(
            (s) =>
                s.close().then<ServerSocket?>((v) => v).onError((_, _) => null),
          ),
        ),
      );
    }
  }

  /// Spawns a Node.js process that loads the Dart test suite at [path].
  ///
  /// Returns that channel along with a [StackTraceMapper] representing the
  /// source map for the compiled suite.
  Future<(Process, StackTraceMapper?)> _spawnProcess(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    int socketPort,
  ) async {
    if (_config.suiteDefaults.precompiledPath != null) {
      return _spawnPrecompiledProcess(
        path,
        platform.runtime,
        suiteConfig,
        socketPort,
        _config.suiteDefaults.precompiledPath!,
      );
    } else {
      return switch (platform.compiler) {
        Compiler.dart2js => _spawnNormalJsProcess(
          path,
          platform.runtime,
          suiteConfig,
          socketPort,
        ),
        Compiler.dart2wasm => _spawnNormalWasmProcess(
          path,
          platform.runtime,
          suiteConfig,
          socketPort,
        ),
        _ => throw StateError('Unsupported compiler ${platform.compiler}'),
      };
    }
  }

  Future<String> _entrypointScriptForTest(
    String testPath,
    SuiteConfiguration suiteConfig,
  ) async {
    return '''
        ${suiteConfig.metadata.languageVersionComment ?? await rootPackageLanguageVersionComment}
        import "package:test/src/bootstrap/node.dart";

        import "${p.toUri(p.absolute(testPath))}" as test;

        void main() {
          internalBootstrapNodeTest(() => test.main);
        }
      ''';
  }

  /// Compiles [testPath] with dart2js, adds the node preamble, and then spawns
  /// a Node.js process that loads that Dart test suite.
  Future<(Process, StackTraceMapper?)> _spawnNormalJsProcess(
    String testPath,
    Runtime runtime,
    SuiteConfiguration suiteConfig,
    int socketPort,
  ) async {
    var dir = Directory(_compiledDir).createTempSync('test_').path;
    var jsPath = p.join(dir, '${p.basename(testPath)}.node_test.dart.js');
    await _jsCompilers.compile(
      await _entrypointScriptForTest(testPath, suiteConfig),
      jsPath,
      suiteConfig,
    );

    // Add the Node.js preamble to ensure that the dart2js output is
    // compatible. Use the minified version so the source map remains valid.
    var jsFile = File(jsPath);
    await jsFile.writeAsString(
      preamble.getPreamble(minified: true) + await jsFile.readAsString(),
    );

    StackTraceMapper? mapper;
    if (!suiteConfig.jsTrace) {
      var mapPath = '$jsPath.map';
      mapper = JSStackTraceMapper(
        await File(mapPath).readAsString(),
        mapUrl: p.toUri(mapPath),
        sdkRoot: Uri.parse('org-dartlang-sdk:///sdk'),
        packageMap: (await currentPackageConfig).toPackageMap(),
      );
    }

    return (await _startProcess(runtime, jsPath, socketPort), mapper);
  }

  /// Compiles [testPath] with dart2wasm, adds a JS entrypoint and then spawns
  /// a Node.js process loading the compiled test suite.
  Future<(Process, StackTraceMapper?)> _spawnNormalWasmProcess(
    String testPath,
    Runtime runtime,
    SuiteConfiguration suiteConfig,
    int socketPort,
  ) async {
    var dir = Directory(_compiledDir).createTempSync('test_').path;
    // dart2wasm will emit a .wasm file and a .mjs file responsible for loading
    // that file.
    var wasmPath = p.join(dir, '${p.basename(testPath)}.node_test.dart.wasm');
    var loader = '${p.basename(testPath)}.node_test.dart.wasm.mjs';

    // We need to create an additional entrypoint file loading the wasm module.
    var jsPath = p.join(dir, '${p.basename(testPath)}.node_test.dart.js');

    await _wasmCompilers.compile(
      await _entrypointScriptForTest(testPath, suiteConfig),
      wasmPath,
      suiteConfig,
    );

    await File(jsPath).writeAsString('''
const { createReadStream } = require('fs');
const { once } = require('events');
const { PassThrough } = require('stream');

const main = async () => {
  const { instantiate, invoke } = await import("./$loader");

  const wasmContents = createReadStream(String.raw`$wasmPath.wasm`);
  const stream = new PassThrough();
  wasmContents.pipe(stream);

  await once(wasmContents, 'open');
  const response = new Response(
    stream,
    {
      headers: {
        "Content-Type": "application/wasm"
      }
    }
  );
  const instancePromise = WebAssembly.compileStreaming(response);
  const module = await instantiate(instancePromise, {});
  invoke(module);
};

main();
''');

    return (await _startProcess(runtime, jsPath, socketPort), null);
  }

  /// Spawns a Node.js process that loads the Dart test suite at [testPath]
  /// under [precompiledPath].
  Future<(Process, StackTraceMapper?)> _spawnPrecompiledProcess(
    String testPath,
    Runtime runtime,
    SuiteConfiguration suiteConfig,
    int socketPort,
    String precompiledPath,
  ) async {
    StackTraceMapper? mapper;
    var jsPath = p.join(precompiledPath, '$testPath.node_test.dart.js');
    if (!suiteConfig.jsTrace) {
      var mapPath = '$jsPath.map';
      mapper = JSStackTraceMapper(
        await File(mapPath).readAsString(),
        mapUrl: p.toUri(mapPath),
        sdkRoot: Uri.parse('org-dartlang-sdk:///sdk'),
        packageMap:
            (await findPackageConfig(
              Directory(precompiledPath),
            ))!.toPackageMap(),
      );
    }

    return (await _startProcess(runtime, jsPath, socketPort), mapper);
  }

  /// Starts the Node.js process for [runtime] with [jsPath].
  Future<Process> _startProcess(
    Runtime runtime,
    String jsPath,
    int socketPort,
  ) async {
    var settings = _settings[runtime]!;

    var nodeModules = p.absolute('node_modules');
    var nodePath = Platform.environment['NODE_PATH'];
    nodePath = nodePath == null ? nodeModules : '$nodePath:$nodeModules';

    try {
      return await Process.start(
        settings.executable,
        settings.arguments.toList()
          ..add(jsPath)
          ..add(socketPort.toString()),
        environment: {'NODE_PATH': nodePath},
      );
    } catch (error, stackTrace) {
      await Future<Never>.error(
        ApplicationException(
          'Failed to run ${runtime.name}: ${getErrorMessage(error)}',
        ),
        stackTrace,
      );
    }
  }

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
    await _jsCompilers.close();
    await _wasmCompilers.close();
    await Directory(_compiledDir).deleteWithRetry();
  });
  final _closeMemo = AsyncMemoizer<void>();
}

Future<List<ServerSocket>> _loopback({int remainingRetries = 5}) async {
  if (!await _supportsIPv4) {
    return [await ServerSocket.bind(InternetAddress.loopbackIPv6, 0)];
  }

  var v4Server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
  if (!await _supportsIPv6) return [v4Server];

  try {
    // Reuse the IPv4 server's port so that if [port] is 0, both servers use
    // the same ephemeral port.
    var v6Server = await ServerSocket.bind(
      InternetAddress.loopbackIPv6,
      v4Server.port,
    );
    return [v4Server, v6Server];
  } on SocketException catch (error) {
    if (error.osError?.errorCode != _addressInUseErrno) rethrow;
    if (remainingRetries == 0) rethrow;

    // A port being available on IPv4 doesn't necessarily mean that the same
    // port is available on IPv6. If it's not (which is rare in practice),
    // we try again until we find one that's available on both.
    unawaited(v4Server.close());
    return await _loopback(remainingRetries: remainingRetries - 1);
  }
}

/// Whether this computer supports binding to IPv6 addresses.
final Future<bool> _supportsIPv6 = () async {
  try {
    var socket = await ServerSocket.bind(InternetAddress.loopbackIPv6, 0);
    unawaited(socket.close());
    return true;
  } on SocketException catch (_) {
    return false;
  }
}();

/// Whether this computer supports binding to IPv4 addresses.
final Future<bool> _supportsIPv4 = () async {
  try {
    var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
    unawaited(socket.close());
    return true;
  } on SocketException catch (_) {
    return false;
  }
}();

/// The error code for an error caused by a port already being in use.
final int _addressInUseErrno = () {
  if (Platform.isWindows) return 10048;
  if (Platform.isMacOS) return 48;
  assert(Platform.isLinux);
  return 98;
}();

/// A [StreamChannelTransformer] that converts a chunked string channel to a
/// line-by-line channel.
///
/// Note that this is only safe for channels whose messages are guaranteed not
/// to contain newlines.
final _chunksToLines = StreamChannelTransformer<String, String>(
  const LineSplitter(),
  StreamSinkTransformer.fromHandlers(
    handleData: (data, sink) => sink.add('$data\n'),
  ),
);
